使用Node.js和typescript开发CLI工具的实践
使用typescript打包成符合ES模块规范的代码,在NodeJS平台上运行一款CLI工具,整个开发过程的落地实践。
背景介绍
目前NodeJS还不能原生地支持typescript(TS),如果基于NodeJS开发项目时想使用TS,需要额外引入相关的工具库,将 typescript 编译成 JavaScript 再交由NodeJS引擎去执行。 开发NodeJS的应用,一般使用commonJS的模块规范,如过想使用ES模块的规范,需要做一些额外的处理(更改配置或者使用类似webpack这样的打包工具)。 开发一款CLI工具,一般把执行脚本放在bin目录下,由系统中的node进程来运行这些JS。在开发的过程中如何随时编写TS代码,随时测试CLI工具的效果呢。 以上三点是在开发过程中需要解决的问题。
实践方案
搭建环境
工程目录,如上面的左图所示,src目录存放TS源码文件,bin目录存放编译后的可执行JS文件。 从上面的右图,可以看出TS编译后,直接导出到了bin目录,生成了符合ES模块规范的目标代码。
自动构建
开放CLI工具时,一般需要链接到本地的目录,执行
npm link -f .
命令,就可以在本地运行注册好的CLI命令了。以本项目为例,运行的效果如上面的右图所示。 那么回到一开始的问题,如何做到一边写ts代码,一边跑本地的命令调试效果呢。可能很多小伙伴会想到,ts有实时编译的功能吗。直接执行命令tsc --watch
就好了吧。 这样做的话,会有一个问题:编译出的js代码无法直接被node运行。
#!/usr/bin/env node
import inquirer from 'inquirer';
import colors from 'colors';
import startup from './main'; // 引用本地相对路径模块
import { author } from './config'; // 引用本地相对路径的模块
const init = async function () {}
#!/usr/bin/env node
import inquirer from 'inquirer';
import colors from 'colors';
import startup from './main'; // 引用本地相对路径模块
import { author } from './config'; // 引用本地相对路径的模块
const init = async function () {}
上面的引用本地相对路径模块的代码,不能被node有效识别。需要转换成下面的格式才可以。
#!/usr/bin/env node
import inquirer from 'inquirer';
import colors from 'colors';
import startup from './main.js'; // 引用本地相对路径模块()
import { author } from './config.js'; // 引用本地相对路径的模块
const init = async function () {}
#!/usr/bin/env node
import inquirer from 'inquirer';
import colors from 'colors';
import startup from './main.js'; // 引用本地相对路径模块()
import { author } from './config.js'; // 引用本地相对路径的模块
const init = async function () {}
需要在引入的本地模块加上js的后缀才能正常运行。因此编写了一个自动构建的脚本,实时监测文件的变化,重写本地模块的路径。
/**
* update the import path for supporting the ESModule
* @param string distJsPath
*/
const updateImportPath = (distJsPath) => {
childProcess.exec('npm run repath', (error, stdout, stderr) => {
if (error !== null) {
console.log('execute command error: ', stderr);
}
console.log(stdout);
console.log(`update the imported path of file [${distJsPath}] done`);
});
};
/**
* monitoring the file change and compile typescript
*/
const watchBuilding = () => {
tsPaths.forEach(tsPath => {
fs.watch(tsPath, { recursive: true }, throttle((evt, filepath) => {
if (!tsReg.test(filepath)) return;
console.log(`${evt} in ${filepath}, ready to compile`);
const jsPath = filepath.replace(tsReg, '.js');
const distJsPath = compiledTSPath + '/' + jsPath;
if (evt === fileEvent.rename) {
if (fs.existsSync(distJsPath)) {
fs.rmSync(distJsPath);
console.log(`remove compiled js file [${distJsPath}].`);
}
return;
}
childProcess.exec('npm run build-ts', (error, stdout, stderr) => {
if (error !== null) {
console.log('execute command error: ', stderr);
}
console.log(stdout);
if (fs.existsSync(distJsPath)) {
setTimeout(() => updateImportPath(distJsPath), 100);
}
console.log(`compile typescript file [${filepath}] done`);
});
}, delay));
});
};
/**
* update the import path for supporting the ESModule
* @param string distJsPath
*/
const updateImportPath = (distJsPath) => {
childProcess.exec('npm run repath', (error, stdout, stderr) => {
if (error !== null) {
console.log('execute command error: ', stderr);
}
console.log(stdout);
console.log(`update the imported path of file [${distJsPath}] done`);
});
};
/**
* monitoring the file change and compile typescript
*/
const watchBuilding = () => {
tsPaths.forEach(tsPath => {
fs.watch(tsPath, { recursive: true }, throttle((evt, filepath) => {
if (!tsReg.test(filepath)) return;
console.log(`${evt} in ${filepath}, ready to compile`);
const jsPath = filepath.replace(tsReg, '.js');
const distJsPath = compiledTSPath + '/' + jsPath;
if (evt === fileEvent.rename) {
if (fs.existsSync(distJsPath)) {
fs.rmSync(distJsPath);
console.log(`remove compiled js file [${distJsPath}].`);
}
return;
}
childProcess.exec('npm run build-ts', (error, stdout, stderr) => {
if (error !== null) {
console.log('execute command error: ', stderr);
}
console.log(stdout);
if (fs.existsSync(distJsPath)) {
setTimeout(() => updateImportPath(distJsPath), 100);
}
console.log(`compile typescript file [${filepath}] done`);
});
}, delay));
});
};
核心的方法是上面列出的两个:watchBuilding(监视typescript的编译)、updateImportPath(更新引入路径)。更多内容可以参考后面工程项目。在本地开发过程中,只需启动一个npm run compile
命令即可实现,ts ---》 js以及路径的重新。使用ts开发完一个小功能后,即可通过link后的cmd命令运行打包后的js文件。
无法加载ESModule
项目中引入了一些三方的类库,在使用ts引入报如下的错误:
src/index.ts:4:8 - error TS1259: Module '"/Users/ancai/code/leah-cli/node_modules/@types/inquirer/index"' can only be default-imported using the 'allowSyntheticDefaultImports' flag
4 import inquirer from 'inquirer';
~~~~~~~~
node_modules/@types/inquirer/index.d.ts:982:1
982 export = inquirer;
~~~~~~~~~~~~~~~~~~
This module is declared with using 'export =', and can only be used with a default import when using the 'allowSyntheticDefaultImports' flag.
src/index.ts:4:8 - error TS1259: Module '"/Users/ancai/code/leah-cli/node_modules/@types/inquirer/index"' can only be default-imported using the 'allowSyntheticDefaultImports' flag
4 import inquirer from 'inquirer';
~~~~~~~~
node_modules/@types/inquirer/index.d.ts:982:1
982 export = inquirer;
~~~~~~~~~~~~~~~~~~
This module is declared with using 'export =', and can only be used with a default import when using the 'allowSyntheticDefaultImports' flag.
解决方法 在tsconfig配置文件中 compilerOptions项下 添加 allowSyntheticDefaultImports = true,表示允许使用合成的默认导入。
{
"compilerOptions": {
"target": "ES2019",
"module": "ESNext",
"allowSyntheticDefaultImports": true
},
}
{
"compilerOptions": {
"target": "ES2019",
"module": "ESNext",
"allowSyntheticDefaultImports": true
},
}
编译出src路径
如果引入到了src外面的文件,比如引入import pkg from '../package.json'
会导致编译后的目录有src路径,这可能是开发人员不希望看到的。 解决方案:避免引入src之外的模块;改变ts编译配置。
- 将所有的源码模块全部放在src目录下,就不会出现该问题。
- 参考文献3 改一下ts的变异配置也可以解决该问题。
import JSON file error
jest不支持ESM
项目中使用jest做单元测试时,出现下面的错误,也是由于jest在node运行环境下不能很好地兼容esm模块规范所致。 解决办法如下:添加额外的参数
--experimental-vm-modules node_modules/.bin/jest
. node --experimental-vm-modules node_modules/.bin/jest 取代单独的 jest 命令 更详细的内容,可以参考文献7
相关参考
- https://github.com/microsoft/TypeScript-Node-Starter
- https://github.com/jeroenouw/ExampleCLI
- https://github.com/ioleo/ts-mocha-example
- https://stackoverflow.com/questions/60205891/import-json-extension-in-es6-node-js-throws-an-error
- https://segmentfault.com/q/1010000038671707
- https://stackoverflow.com/questions/60935889/cant-do-a-default-import-in-angular-9
- https://stackoverflow.com/questions/59709939/jest-cannot-use-import-statement-outside-a-module